Skip to content

NUT-XX wallet quote lookup: wire-contract fixes, signed lookups with persistence, mintable-only filter - #9

Open
vnprc wants to merge 7 commits into
TheMhv:feat/mint_quote_lookup_walletfrom
vnprc:nutxx-wallet-v2
Open

NUT-XX wallet quote lookup: wire-contract fixes, signed lookups with persistence, mintable-only filter#9
vnprc wants to merge 7 commits into
TheMhv:feat/mint_quote_lookup_walletfrom
vnprc:nutxx-wallet-v2

Conversation

@vnprc

@vnprc vnprc commented Aug 10, 2026

Copy link
Copy Markdown

Builds directly on this branch's connector and test scaffolding, fixing the wire
contract and completing the wallet side of the NUT-XX quote lookup. One commit per
logical change:

  • fix(wallet): match the NUT-XX wire contract in the quote lookup connector
    method-less POST /v1/mint/quote/pubkey, the {"quotes": [...]} envelope, and
    per-quote reconstruction by dispatching on each object's method field (the
    derived enum form can't parse the flat spec'd objects). Malformed entries are
    skipped with warnings instead of failing the whole batch.
  • test(nutxx): drop unresolvable import in the pubkey quote db test — two-line
    fix for a test-feature build break in the base branch; happy to move this into
    the mint PR instead.
  • fix(wallet): stamp created_time when a mint quote row is first stored
    the wallet's mint_quote.created_time column was never written (always 0);
    now set once on first store, preserved across updates. Independent bug, fix
    benefits all wallet write paths.
  • feat(wallet): add Wallet::fetch_mint_quotes_by_pubkey — signs the lookup
    per NUT-XX, validates returned quotes against the requested pubkeys, and
    persists them with their signing key stamped, so a polling caller can discover
    and mint quotes it never saw created. Writes are skipped when nothing changed.
  • feat(nutxx): optional mintable-only filter for quote lookup — opt-in
    only_mintable request flag (absent from the wire when false, so fully
    backward compatible) bounding the response to quotes with
    amount_paid > amount_issued, filtered in the mint's DB query.
  • test(nutxx): end-to-end wallet lookup coverage in the pure test harness
    real mint + real wallet round trip: sign, verify, look up, persist.

Every commit compiles and tests green standalone; full suite green at the tip
(cdk, cashu, cdk-common, cdk-sql-common, cdk-axum, sqlite backend, and
the pure integration tests).

vnprc added 7 commits August 10, 2026 11:42
…ctor

The connector's post_mint_quote_by_pubkey diverged from the endpoint the
mint actually serves:

- The lookup is method-agnostic: one POST to /v1/mint/quote/pubkey
  answers for every payment method at once, so the per-method dispatch,
  the {method} path segment, and the method parameter on the
  MintConnector trait are gone.
- The mint wraps the quotes in a {"quotes": [...]} envelope, with each
  quote flattened to its bare NUT-04 response object rather than
  MintQuoteResponse's externally tagged form. Parse the envelope, then
  reconstruct each variant by dispatching on the per-quote "method"
  field - the derived Deserialize for the enum cannot parse the
  flattened objects directly.
- A missing or malformed "method" is a protocol error: guessing bolt11
  would reinterpret a paid bolt12/onchain/custom quote's accounting
  fields as an unpaid Bolt11 response. Such entries are skipped with a
  warning naming the quote id, plus a summary warning with
  skipped/total counts, rather than failing the whole batch - one bad
  entry should not hide every other quote from a caller reconciling
  many pubkeys at once.

The in-process test connector now forwards the typed request fields
and converts with the shared From impl, which preserves each quote's
own method instead of stamping the caller's.

MockTransport-backed tests cover the mixed-method reconstruction and
the method-agnostic URL, the bolt12/onchain variants, the
missing-method rejection, and the skip-malformed and all-malformed
batch behaviors.
get_mint_quote_by_public_key imported unique_string through the test
module root, where the helper is not re-exported (it is private to this
module and already in scope). Any build that enables cdk-common's
"test" feature - the sqlite backend's test suite, for one - failed to
compile on the unresolved path. Delete the import; calls resolve to the
module-level definition directly.
The wallet's mint_quote table gained a created_time column (for
ordering) whose value was never written: add_mint_quote's INSERT omits
the column, so every row keeps the schema default of 0. The in-memory
MintQuote struct does not carry the field, so no construction site -
mint_quote, fetch_mint_quote, or any other path that funnels into
add_mint_quote - could populate it either.

The mint's NUT-04 responses carry no creation time, so the honest value
is the wallet's own first sighting: bind unix_time() in the INSERT, the
same way the p2pk_signing_key insert in this module already stamps its
created_time. The column stays out of the ON CONFLICT UPDATE clause,
so re-storing an existing quote preserves the original first-store
time. Fixing the single shared store path covers every quote
construction site at once.

The regression test reads the column back over a raw connection, since
the public struct does not expose it, and plants a sentinel before a
second store to prove updates preserve rather than restamp it.
Give the wallet a high-level entry point for the NUT-XX lookup: sign,
fetch, validate, and store, so callers get reconciled MintQuote records
instead of raw connector responses. Modeled on fetch_mint_quote (cdk
convention: fetch_ = fetch-and-store).

- The mint's NUT-06 pubkey binds the signatures to this mint; a mint
  that does not advertise one fails fast with Error::MissingPubkey.
- secret_keys are deduplicated by pubkey before the request is built,
  so duplicates don't burn slots of the MAX_LOOKUP_PUBKEYS budget, and
  an empty set short-circuits without any network call.
- Every returned quote is checked against the requested pubkeys; a
  quote with a missing or unrequested pubkey is logged and dropped
  rather than stored, since this method writes mint responses to the
  wallet database and cannot take the mint's word for whose they are.
- Reconciliation reuses apply_mint_quote_response and constructs
  unseen quotes exactly as fetch_mint_quote does, then stamps the
  signing key. Writes are change-guarded: apply_mint_quote_response's
  bool means "not stale", not "changed" - it reports true for a
  byte-identical repeat - so the guard compares the fields the
  response can touch before and after instead. A caller polling on an
  interval must not rewrite an unchanged quote history on every pass.

The mock connector now captures lookup requests and counts
get_mint_info calls so tests can assert what actually went out. Tests
cover sign/verify round-trip and mint-binding, the oversized-batch
error, store-and-stamp behavior, the unrequested-pubkey drop, the
missing-mint-pubkey failure, the empty-input short-circuit, and write
idempotency witnessed through the quote row's optimistic-concurrency
version.
Add an opt-in request filter so callers can bound the pubkey lookup
response to quotes that are still mintable (amount_paid >
amount_issued). The wire format is additive and backward compatible:
only_mintable is absent from the wire when false (skip_serializing_if),
so a mint that predates the field sees the request an old client would
send, and an old client deserializing a request defaults it to false.

Threads the flag from Wallet::fetch_mint_quotes_by_pubkey through the
NUT-XX request, the axum handler, Mint::get_mint_quote_by_pubkey, and
the mint database trait down to the SQL query, which adds
"AND amount_paid > amount_issued" to the WHERE clause when set - this
also skips the per-quote payments/issuance follow-up queries for
excluded rows. The filter is a response-bounding convenience only: it
is not part of the NUT-20 signed message and carries no security
weight. The mint logs one debug line after the query with the pubkey
and result counts and the flag, so operators can see lookup traffic
without turning on request tracing.

Serde tests pin all three wire shapes (absent, false, true). The
wallet test asserts the flag lands on the outgoing request verbatim,
and mint-side integration coverage creates three quotes for one key -
unpaid, paid-unissued, fully-issued - and checks the filter returns
exactly the paid-unissued one when set, and all three when not.
Exercise Wallet::fetch_mint_quotes_by_pubkey against a real in-process
Mint through the existing DirectMintConnection: the wallet signs its own
lookup challenge, the mint verifies it, and the returned quote lands in
the wallet database with its signing key stamped. A negative case covers
a key with no quotes (empty result, not an error).

Lives in cdk-integration-tests rather than crates/cdk/tests so it reuses
the complete direct connector instead of a stub-heavy local one.
A cached mint record can lack a pubkey while still satisfying the
metadata cache: it may predate the mint advertising one, or the stored
form may fail to parse (the wallet store currently persists this column
in an encoding its reader does not accept, reported separately).
Trusting the cache meant fetch_mint_quotes_by_pubkey failed with
MissingPubkey forever, starving a polling caller even though the live
mint advertises a pubkey.

Fall back to one forced fetch_mint_info refresh before erroring, which
restores the pubkey for the life of the process. A mint that genuinely
has no pubkey still fails with MissingPubkey after the refresh.
@vnprc

vnprc commented Aug 10, 2026

Copy link
Copy Markdown
Author

Added a commit to fix a restart bug i found in testing. The mint identity pubkey is not stored correctly and dropped on restart, when it attempts to read from mintinfo cache. This breaks the "get quote by locking key" protocol. See cashubtc#2317

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant